跳到主要内容

MySQL 主键类型选择

主键类型概述

MySQL 主键的选择是数据库设计中的重要决策,直接影响系统的性能、扩展性和维护性。常见的主键类型包括自增整数、UUID、雪花 ID 等,每种类型都有其适用场景和优缺点。

自增主键特性与潜在问题

自增主键不一定连续递增

很多开发者认为 MySQL 自增主键一定是连续递增的,但实际上有多种情况会打破这个性质:

1. 事务回滚导致的空洞

// 示例:并发插入时的事务回滚
func insertWithRollback(db *sql.DB) {
tx, _ := db.Begin()
defer tx.Rollback()

// 这个插入会消耗自增值,但回滚后留下空洞
_, err := tx.Exec("INSERT INTO users (name) VALUES (?)", "张三")
if err != nil {
tx.Rollback() // 自增值已消耗,不会回退
return
}

// 模拟业务逻辑错误导致回滚
if rand.Intn(2) == 0 {
tx.Rollback() // ID 空洞产生
return
}

tx.Commit()
}

2. 删除操作造成的空洞

-- 原始数据: 1, 2, 3, 4, 5
DELETE FROM users WHERE id IN (2, 4);
-- 结果: 1, 3, 5 (产生空洞)

-- 新插入数据会从最大值+1开始,而不是填补空洞
INSERT INTO users (name) VALUES ('新用户'); -- ID = 6,而不是2

3. 批量插入的原子性

func batchInsertGaps() {
// MySQL 在批量插入时,如果中途失败,已分配的ID不会回收
values := []string{
"('用户1')", // 假设分配 ID = 100
"('用户2')", // 分配 ID = 101
"('用户3')", // 分配 ID = 102,但此处违反唯一约束
"('用户4')", // 不会插入
}

query := "INSERT INTO users (name) VALUES " + strings.Join(values, ",")
_, err := db.Exec(query)
// 如果在第3条记录失败,ID 100,101已消耗,102-104产生空洞
}

4. 服务器重启的影响

在 MySQL 5.7 及之前版本中,服务器重启会导致自增值重新计算:

-- 重启前最大ID是100,但有删除的记录
-- 重启后 AUTO_INCREMENT 可能重置为 MAX(id) + 1
-- 如果最大的实际ID是98,下一个ID就是99,而不是101

5. 手动指定 ID 值

func manualIdAssignment(db *sql.DB) {
// 手动指定ID值会影响自增序列
_, err := db.Exec("INSERT INTO users (id, name) VALUES (?, ?)", 1000, "特殊用户")
if err != nil {
return
}

// 下一个自动分配的ID将从1001开始,跳过了很多数字
_, err = db.Exec("INSERT INTO users (name) VALUES (?)", "普通用户") // ID = 1001
}

自增主键的适用场景

UUID 主键特性分析

UUID 的优点

  1. 全局唯一性: 无需中心化协调即可保证唯一性
  2. 分布式友好: 各节点独立生成,无冲突
  3. 安全性: 无法推测下一个ID值,防止遍历攻击

UUID 的缺点

1. 存储和性能开销

import "github.com/google/uuid"

func uuidOverhead() {
// UUID 字符串形式: 36字节 (包含4个连字符)
uuidStr := uuid.New().String() // "f47ac10b-58cc-4372-a567-0e02b2c3d479"

// UUID 二进制形式: 16字节
uuidBinary := uuid.New()

// 对比自增ID: 8字节 (BIGINT)
autoIncId := int64(12345)

fmt.Printf("UUID字符串: %d字节\n", len(uuidStr)) // 36字节
fmt.Printf("UUID二进制: %d字节\n", len(uuidBinary)) // 16字节
fmt.Printf("自增ID: %d字节\n", unsafe.Sizeof(autoIncId)) // 8字节
}

2. 索引性能问题

3. 查询性能测试

func compareQueryPerformance() {
// 自增ID查询 - 高效的范围扫描
autoIncQuery := `
SELECT * FROM orders
WHERE id BETWEEN 100000 AND 200000
ORDER BY id
`

// UUID查询 - 无法利用索引顺序
uuidQuery := `
SELECT * FROM orders
WHERE created_time BETWEEN '2024-01-01' AND '2024-12-31'
ORDER BY created_time -- 需要额外排序
`

// 自增ID的优势:
// 1. 范围查询直接利用主键索引
// 2. ORDER BY 免费(索引天然有序)
// 3. 相邻记录在同一页面,缓存友好
}

UUID 的适用场景

雪花 ID (Snowflake) 的改进

雪花 ID 是 Twitter 开源的分布式 ID 生成算法,它结合了自增 ID 和 UUID 的优点,同时规避了它们的主要缺点。

雪花 ID 结构

雪花 ID 生成实现

type SnowflakeGenerator struct {
mutex sync.Mutex
epoch int64 // 起始时间戳
machineId int64 // 机器ID
sequence int64 // 序列号
lastTime int64 // 上次生成时间
}

func NewSnowflake(machineId int64) *SnowflakeGenerator {
return &SnowflakeGenerator{
epoch: 1640995200000, // 2022-01-01 00:00:00
machineId: machineId,
sequence: 0,
lastTime: 0,
}
}

func (s *SnowflakeGenerator) NextID() (int64, error) {
s.mutex.Lock()
defer s.mutex.Unlock()

now := time.Now().UnixMilli()

// 时钟回拨检测
if now < s.lastTime {
return 0, fmt.Errorf("时钟回拨: %d ms", s.lastTime-now)
}

if now == s.lastTime {
// 同一毫秒内,序列号递增
s.sequence = (s.sequence + 1) & 0xFFF // 12位最大值4095
if s.sequence == 0 {
// 序列号溢出,等待下一毫秒
now = s.waitNextMillis(s.lastTime)
}
} else {
// 新的毫秒,序列号重置
s.sequence = 0
}

s.lastTime = now

// 组装ID: 时间戳(41位) + 机器ID(10位) + 序列号(12位)
id := ((now - s.epoch) << 22) | (s.machineId << 12) | s.sequence
return id, nil
}

func (s *SnowflakeGenerator) waitNextMillis(lastTime int64) int64 {
now := time.Now().UnixMilli()
for now <= lastTime {
time.Sleep(100 * time.Microsecond)
now = time.Now().UnixMilli()
}
return now
}

雪花 ID 的优点

1. 趋势递增保证索引性能

2. 高性能并发生成

func benchmarkIdGeneration() {
snowflake := NewSnowflake(1)

// 单机QPS测试
start := time.Now()
var wg sync.WaitGroup
idCount := 100000

for i := 0; i < 10; i++ { // 10个goroutine
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < idCount/10; j++ {
id, _ := snowflake.NextID()
_ = id
}
}()
}

wg.Wait()
duration := time.Since(start)
qps := float64(idCount) / duration.Seconds()

fmt.Printf("生成%d个ID,用时%v,QPS: %.0f\n", idCount, duration, qps)
// 典型输出: 生成100000个ID,用时45ms,QPS: 2222222
}

3. 包含业务信息

func extractSnowflakeInfo(id int64) {
// 提取时间戳 (高41位)
timestamp := (id >> 22) + 1640995200000 // 加上epoch
createTime := time.UnixMilli(timestamp)

// 提取机器ID (中间10位)
machineId := (id >> 12) & 0x3FF

// 提取序列号 (低12位)
sequence := id & 0xFFF

fmt.Printf("ID: %d\n", id)
fmt.Printf("创建时间: %s\n", createTime.Format("2006-01-02 15:04:05.000"))
fmt.Printf("机器ID: %d\n", machineId)
fmt.Printf("序列号: %d\n", sequence)
}

雪花 ID 的缺点

1. 时钟依赖性

type ClockBackwardHandler struct {
maxBackward int64 // 最大可容忍的时钟回拨(ms)
}

func (h *ClockBackwardHandler) HandleClockBackward(backward int64) error {
if backward > h.maxBackward {
return fmt.Errorf("时钟回拨过大: %dms, 超过阈值: %dms",
backward, h.maxBackward)
}

// 小幅回拨,等待时间追上
time.Sleep(time.Duration(backward) * time.Millisecond)
return nil
}

2. 机器ID管理复杂性

type MachineIdManager struct {
registry map[string]int64 // IP -> MachineId 映射
mutex sync.RWMutex
}

func (m *MachineIdManager) GetMachineId() (int64, error) {
// 实际项目中需要考虑:
// 1. 如何分配机器ID (配置文件/注册中心)
// 2. 机器下线后ID如何回收
// 3. 集群扩容时ID如何分配
// 4. 跨数据中心的ID分配策略

localIP := getLocalIP()

m.mutex.RLock()
if id, exists := m.registry[localIP]; exists {
m.mutex.RUnlock()
return id, nil
}
m.mutex.RUnlock()

return m.allocateNewMachineId(localIP)
}

雪花 ID 适用场景

主键类型选择决策

决策矩阵

场景自增IDUUID雪花ID
单机系统✅ 最优❌ 过度设计❌ 复杂度高
分布式系统❌ 冲突风险✅ 可用✅ 推荐
高并发写入✅ 性能好❌ 性能差✅ 性能好
存储敏感✅ 8字节❌ 16字节✅ 8字节
安全性要求❌ 可预测✅ 随机✅ 半随机
时序需求✅ 严格递增❌ 无序✅ 趋势递增

实际应用建议

// 根据不同场景选择合适的主键策略
func selectPrimaryKeyStrategy(scenario string) string {
switch scenario {
case "小型网站", "内部系统", "MVP产品":
return "自增ID - 简单高效,满足需求"

case "大型分布式系统", "微服务架构":
return "雪花ID - 平衡性能与分布式需求"

case "多租户SaaS", "数据同步频繁":
return "UUID - 全局唯一,冲突概率极低"

case "金融系统", "对外API":
return "雪花ID + 业务前缀 - 可追溯且安全"

default:
return "根据具体需求综合评估"
}
}

正确的主键选择需要综合考虑系统架构、性能要求、扩展性需求和维护成本。没有银弹方案,只有最适合当前场景的选择。在系统演进过程中,主键策略也可能需要相应调整。